Skip to content

Team hub phase 1: sanitise synced events, deploy tooling, member onboarding - #7

Merged
datj9 merged 46 commits into
mainfrom
feat/hub-web-ui
Sep 21, 2026
Merged

datj9 merged 46 commits into
mainfrom
feat/hub-web-ui

Conversation

@datj9

@datj9 datj9 commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Phase 1 of the team hub: the lg-hub daemon, the sanitisation that makes syncing
safe, and everything needed to deploy it on a WireGuard mesh.

The defect this branch exists to fix

syncRun pushed .loomgraph/runs/<id>/events.jsonl lines verbatim. The state
projection is a genuine hand-written allowlist, but the event stream bypassed it
entirely, so the hub received raw node_finished.error (the claude and codex
adapters fold the agent's full result text and stderr into it), raw
run_finished.error, run_started.cwd with the home directory and username, the
interpolated human_requested.question — {{vars.x}} and
{{nodes.x.output}} already substituted — and the verbatim
human_resolved.answer.

The README's guarantees described only the projection, which is how unsafe
deployment specs came to be written against them.

It matters more than a normal leak because the hub cannot take it back: the
events table carries no-update/no-delete triggers, so anything ingested is
permanent and visible to every read-scoped member. Root cause was that no
test asserted anything about synced event content
.

Fixes

Fix Where
Per-kind EVENT_DATA_ALLOWLIST + sanitizeEventLine src/team/sync.ts, wired into buildBatch
hostname threaded through as a required field ProjectionIdentity in src/team/project.ts
Newline in an error no longer 400s the batch rejectControlInText in src/hub/wire.ts
Control-char / ANSI strip stripControl in src/team/project.ts
Repo opt-in now gates manual syncs too src/commands/sync.ts

Design decisions worth not undoing:

  • Sanitise at push time, not emission. The local log stays raw for debugging;
    only the copy crossing to the hub is filtered, so lg-hub export still
    reproduces ingested lines byte for byte.
  • In buildBatch, not syncRun. buildBatch is the one choke point both
    lg sync and the live LiveBatcher pass through.
  • safeText order is strip → rewrite → mask → cap. An ESC spliced into a
    secret (sk-ant-api03<ESC>[0m-XXXX) defeats the masker; stripping afterwards
    reassembles it in clear. Capping must stay last, since a masked token is already
    first4 + "...". Pinned by a regression test.
  • Batches stay all-or-nothing per seq. Per-line skipping was considered and
    rejected: a partial-accept highWaterSeq would cover unstored lines and the
    client advances its cursor solely on that number, trading a loud repeating 400
    for silent permanent data loss.

lg sync <runId> and --all never called repoSyncEnabled — only the live
batcher did — so a repo that had never run lg sync --enable could still push
every run it had. Now gated, before the hub-config check.

Deploy

deploy/ provisions the hub with no deployment's addresses baked in; they live in
deploy/hub.env, which is gitignored. Required variables abort by name rather
than defaulting, because a provisioning script that guesses an IP converges the
wrong network.

  • install-hub.sh — idempotent, installs from a local tarball (loomgraph is not
    published, so npm i -g loomgraph cannot work)
  • lg-hub.service — a template; the installer substitutes the bind address
  • backup-hub.sh — VACUUM INTO plus chain verify. Copying hub.db alone is not
    a backup; WAL mode means three files
  • netbird-acl.sh — converges the ACL model, dry-run by default, typed
    confirmation before disabling the All → All policy, which is disabled and never
    deleted so rollback is one PUT
  • enroll-member.sh — the network half of onboarding, which loomgraph itself has
    no knowledge of

Docs

  • member-quickstart.md — the page you hand a new colleague
  • hub-onboarding.md — what syncing actually shares, and why it cannot be undone
  • hub-operations.md — operator runbook; restore and chain-verify tested end to
    end against a live WAL

README: corrected four stale claims — the npm install that cannot work, "no web
UI, that is phase 4" (it ships, on by default), "one SQLite file" (three, in WAL),
and "What the hub receives", which omitted the raw event channel.

Verification

764 tests across 39 files, typecheck clean. Deployed and verified against a live
mesh: 10/10 ACL acceptance criteria pass, the hub answers {"ok":true} on its
mesh address only, and the operator retains SSH to every host.

Not covered: the negative criteria — that a member peer reaches the hub and
nothing else — can only be proven by running netbird-acl.sh --verify --from-member from an enrolled member's machine.

datj9 and others added 30 commits August 25, 2026 09:55
Reverses three documented non-goals - no daemon, no signal bus or inbox, no
raw transcript upload - and records what each one cost. Covers component
boundaries, wire protocol, identity, storage, the inbox, the web UI, failure
behavior, a phased plan, and the threat model behind all of it.

Four decisions are recorded with the losing argument kept rather than dropped:

D-1  the push is the sharing decision. Sync is opt-in per repo, pushed run
     events are team-readable, briefs stay private to the sender until shared.
D-1b the acceptor names the graph. An inbox message reaches an agent only as
     pre-fenced data, never as the prompt that chose the task.
D-3  SQLite is the hub's truth; JSONL is a derived export. Revision 1 had it
     backwards and claimed append-only was a physical property of a file,
     which is false - sed -i disproves it. The laptop's event log is unchanged.
D-4  no central conversation store, on measured evidence: 61% of 80 sampled
     transcripts on the author's machine carry a credential shape scan.ts
     already recognises, and that is a floor, not an estimate.

Nothing under src/ is built yet. Three questions in section 14 need answering
before phase 1, one of which - where the brief encryption key is wrapped - has
no option that is both unattended-restartable and safe from a root operator.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Fifteen commits for phase 1, then phases 2-5 at commit granularity, with a
delegation verdict per commit. An adversarial review of the first draft found
four defects that produce a wrong result rather than a failing build, all
verified against the tree before being fixed here:

- events was WITHOUT ROWID while cursors keyed on (received_at, rowid).
  Verified impossible: SELECT rowid fails with "no such column: rowid".
- RunState carries content, not just status. vars values and nodes[*].output
  are raw agent stdout, so pushing a checkpoint verbatim would publish secrets
  team-readable in phase 1, a phase before any masking exists. Commit 1.10 now
  projects both out on the member's machine before anything leaves it.
- INSERT OR REPLACE on events fires the append-only delete trigger, and the
  shortest path to a green test is deleting the trigger. Spelled out instead.
- scan.test.ts asserts the complete ordered rule list, so adding a rule without
  updating it is red.

Also settled what the review found under-specified: the wire types and that
event lines are stored verbatim rather than re-serialized, the hash chain order,
error body shapes, the body cap, hashToken's input, timingSafeEqual over
equal-length digests, the data dir, the shebang, node >= 22.13 for node:sqlite,
and that the batcher must gate on the repo opt-in, unref its timer, catch every
batch promise, and compose with the existing onEvent rather than replace it.

Design doc amended to match: no WITHOUT ROWID, a chain_head table, members in a
table rather than members.jsonl, no admin http route, user_version, and a new
section 4.1 on what a pushed state omits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Commit 1.4 typed EventBatch.state as ProjectedState while commit 1.10 declared
that interface, so 1.4 could not compile on its own. The type is wire
vocabulary and belongs in src/hub/wire.ts; 1.10 now supplies only the function
that produces it, and imports the type.

Two consequences worth naming. The type omits streamId, because EventBatch
already carries it at top level - which is also what makes it buildable before
commit 1.9 adds the field to RunState. And vars becomes varKeys: string[]
rather than a map with nulled values, because a nulled map still has a slot a
later change can refill with nothing failing, whereas a list of key names has
nowhere to put a value at all.
…wire

Two defects from review of 8402e49.

The plan had moved ahead of the design doc it defers to. Its own header says the
design wins on disagreement, so introducing varKeys in the plan while design
4.1 still described a map with nulled values left the plan in violation of its
own governance rule - with the better content. Design 4.1 now leads with the
varKeys shape and the reason for it, and the plan follows.

EventBatch names its run twice, once at the top level and once inside the
projected state, and nothing said what happens when the two disagree. A client
could push runId A carrying a state describing run B and the hub would store a
row whose status, cost and node table belong to a different run: a wrong answer
that never errors, which is the failure class the plan's preamble exists to
catch. eventBatchSchema now refuses any batch where state.runId or
state.graphName disagrees with the top-level value, 1.4 gains the two
assertions, and 1.7 maps the refusal to 400 run identity mismatch with nothing
stored.

Refusal rather than precedence, and refusal rather than dropping the two fields
from ProjectedState - dropping them would leave HubStore.runState() returning
an object that cannot say which run it describes, and would make the jsonl
export reconstruct identity by joining runs.
datj9 and others added 16 commits August 26, 2026 00:01
# Conflicts:
#	src/commands/resume.ts
#	src/handoff/scan.test.ts
#	src/handoff/scan.ts
Add a self-contained web UI (one embedded HTML document, no build step, no external requests) served by `lg-hub serve` on the same loopback origin as the API. It authenticates with a pasted bearer token kept in localStorage - no login endpoint, no loopback bypass - and renders runs (list + node/event timeline), the activity feed, and the member roster (list/create/revoke) using textContent only so an untrusted transcript cannot inject markup.

Wire the storage methods that already existed to HTTP: GET /v1/runs (read scope), GET/POST /v1/members and POST /v1/members/:keyId/revoke (admin scope). Serving the UI is behind --no-ui for an API-only bind.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`syncRun` pushed `.loomgraph/runs/<id>/events.jsonl` lines verbatim. The
projection (`projectState`) is a genuine allowlist, but the event stream
bypassed it entirely, so the hub received raw `node_finished.error` (the claude
and codex adapters fold the agent's full result text and stderr into it), raw
`run_finished.error`, `run_started.cwd` (home dir + username), the INTERPOLATED
`human_requested.question` (`{{vars.x}}` and `{{nodes.x.output}}` already
substituted) and the verbatim `human_resolved.answer`. The hub's `events` table
has no-update/no-delete triggers, so a leak there is permanent and visible to
every read-scoped member.

Four fixes:

- `EVENT_DATA_ALLOWLIST` + `sanitizeEventLine` in `src/team/sync.ts`: every
  event kind names its publishable `data` fields, each marked `pass` (engine
  identifiers, enums, numbers) or `text` (run through the same rewrite/mask/cap
  the projection applies). Unnamed fields are dropped; an unclassifiable line is
  dropped rather than passed through. Wired into `buildBatch`, not `syncRun`,
  because `buildBatch` is the one choke point both `lg sync` and the live
  `LiveBatcher` go through. The local log stays raw - sanitising at push time
  preserves "export reproduces ingested lines byte for byte".

- `hostname` threaded through as a REQUIRED field of the new
  `ProjectionIdentity`. `rewritePaths` had always accepted it, no caller on the
  sync path supplied it, and the machine hostname published unrewritten for the
  whole of phase 1. Two separate opts declarations are how it went missing, so
  `ProjectionOpts` is now an alias rather than a second type.

- `rejectControlInText` in `src/hub/wire.ts`: a node error is routinely a
  multi-line stack trace, and refusing the newline 400d the whole batch. The
  cursor only advances on a 2xx, so that run could never sync again. Tab, LF and
  CR are allowed in error text only; identity strings keep `rejectControl`.

- `stripControl` in `src/team/project.ts`, running FIRST in `safeText`: many
  CLIs colour stderr, and an ESC would wedge sync the same way. It also defeats
  the masker - `sk-ant-api03<ESC>[0m-XXXX` matches nothing, and stripping after
  the mask would reassemble the secret in clear. Order is strip -> rewrite ->
  mask -> cap and is pinned by a regression test; any other arrangement is
  exploitable.

The root cause was that no test asserted anything about synced event content.
`src/team/sync-redaction.test.ts` now does, covering each event kind's fields,
the ordering property, and the wedge cases. 759 tests pass, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…and the web UI

- `npm i -g loomgraph` does not work: the package is not published, and the
  registry returns Not found. Replaced with clone + build + `npm link`, and the
  other two binaries (`lg-handoff`, `lg-hub`) are now named.

- "the run's event lines verbatim" was the claim that caused unsafe deployment
  specs to be written. The section described only the state projection and
  omitted the raw event channel entirely. It now documents the per-kind `data`
  allowlist, names the fields that carry operator text, and states that
  sanitising happens at push time so the local log stays raw and `lg-hub export`
  still reproduces ingested lines byte for byte.

- "No web UI - that is phase 4" is false: it shipped in fb12c2d and is ON by
  default. It gets its own section, including the caveat that it keeps the
  bearer token in `localStorage` - harmless on the loopback default, not
  harmless on a plaintext `http://` origin behind `--behind-tls-proxy`. Also
  corrected in "What this is not", where the UI is not read-only: it can add and
  revoke members.

- "a single SQLite database" understates the on-disk footprint. WAL mode means
  three files, and copying `hub.db` alone while the server runs silently drops
  every committed write still in the WAL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tcher

`repoSyncEnabled` was consulted in exactly one place - `src/team/batch.ts`, the
live batcher. `lg sync <runId>` and `lg sync --all` never called it, so a repo
that had never run `lg sync --enable` could still push every run it had. The
opt-in read as a per-repo consent control while only gating one of the two push
paths.

That matters more than a normal flag bug because the hub cannot take it back:
the `events` table carries no-update/no-delete triggers, so a run pushed out of a
repo nobody meant to share is permanent and visible to every read-scoped member.
Removing it means hand-dropping a trigger on the production database.

`syncCommand` now refuses with exit 1 and a message naming `lg sync --enable`.
The check runs BEFORE `loadHubConfig`: when neither the opt-in nor the enrollment
exists, pointing at `lg enroll` would send the operator to configure a hub this
repo still would not push to.

Tests 17-21 cover a single run, `--all`, precedence over the missing enrollment,
a `hub.json` whose `sync` is `"true"` rather than `true`, and the enable-then-sync
sequence. Existing push tests now seed the opt-in, which is the behaviour change
made visible. 764 tests pass, typecheck clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything needed to stand a team hub up on a WireGuard mesh, with no
deployment's addresses baked in: they live in `deploy/hub.env` (gitignored;
`hub.env.example` is the template). A REQUIRED variable aborts the script by
name rather than defaulting, because a provisioning script that guesses an IP
converges the wrong network - and this repo is public, so shipping one operator's
mesh map as a default would be both wrong and rude.

- `install-hub.sh` - idempotent provisioning. Installs from a LOCAL tarball,
  never the registry: loomgraph is not published, so `npm i -g loomgraph` cannot
  work. Preflight refuses to proceed if the mesh interface is missing, the hub IP
  is not assigned to it, or the port is held by something else.
- `lg-hub.service` - the unit, now a template: `@MESH_IP@` and `@HUB_PORT@` are
  substituted at install time so the installed file carries concrete values
  (`systemctl cat` showing a variable would hide the bind address, which is the
  one thing an operator needs to read). The three ExecStart flags each have a
  comment block; `--no-ui` is deliberate, since the UI keeps its bearer token in
  localStorage and there is no browser use case on this bind.
- `backup-hub.sh` - `VACUUM INTO` plus a chain verify, with SQLITE_BUSY retry.
  Copying `hub.db` alone is not a backup: WAL mode means three files.
- `netbird-acl.sh` - converges the ACL model (members reach the hub on one TCP
  port and nothing else, operator keeps mesh SSH, `Default` All -> All disabled
  but never deleted, for one-call rollback). Dry-run by default, typed
  confirmation before the lockout step, `--verify` with positive criteria and
  `--verify --from-member` for the negative ones. The API token goes to curl via
  `--config -` on stdin, never argv. The sandbox peer and the stale-policy
  cleanup are both optional and skip cleanly when unconfigured.

Nothing here has been run against live infrastructure. Every step needs root and
is the operator's to run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two audiences, two documents, both written against `deploy/hub.env` variables
rather than any one deployment's addresses.

`hub-onboarding.md` is what a colleague reads before `lg enroll`. It leads with
the property that actually governs the decision - anything reaching the hub is
readable by every member and can never be deleted, because the events table
aborts UPDATE and DELETE by trigger - and then states plainly what each of the
two channels publishes. Both are filtered now, so the page says so; what it
refuses to imply is that filtering is a proof. The masking rules recognise shapes
someone thought of, and an organisation's own token format is probably not one of
them. The test it asks the reader to apply is "if the masker missed this, would I
mind the whole team reading it, forever".

`hub-operations.md` is the runbook. Its goal is that someone who did not build
the hub can restore it from a backup and prove the restored database is intact
using nothing but that page. The restore and chain-verify procedure was tested
end to end, including against a live 226 KB WAL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hub.env` is sourced by bash, so `LOOMGRAPH_HUB_PEER_NAME=the hub peer` makes the
shell try to run `hub` as a command, and the template shipped with exactly that
as its default. The failure surfaces as `hub.env: line N: <word>: command not
found` before any script logic runs, which points at the wrong place entirely.

Quoted the default, noted the rule at the top of the file, and flagged it on
NETBIRD_DEAD_POLICY, whose real value is a NetBird-generated sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`npm pack` is a documented step in the hub install path (loomgraph is not
published, so install-hub.sh consumes a local tarball). The artifact it drops in
the repo root should never be a candidate for commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The health-body check runs through Python's `re.search`, so a POSIX class like
`[[:space:]]` is not a character class - it is a nested set. It fails to match
and only emits a FutureWarning, which reads as "the hub is unhealthy" when the
body was fine all along. Documented the flavour, and included the real payload
and a working pattern so the next person copies one that works.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A member needs two independent grants and loomgraph has no NetBird coupling at
all - nothing in `src/` references the mesh - so the network half was a manual
console step with no record of how it is meant to be done. This script is that
record.

- `--new <name>` mints a one-off setup key whose `auto_groups` puts the new peer
  straight into `loomgraph-members`, so there is no window where a peer is on
  the mesh but ungrouped, and no second step to forget. Keys are single-use and
  expire in 24h by default: a setup key with no end date is a credential sitting
  in somebody's chat history.
- `--peer <ref>` adds an existing peer by name, hostname, mesh IP or id. The
  group PUT replaces the peer list, so the current members are read back and the
  new id appended - sending just the new one would silently evict everybody
  already in the group.
- `--list` shows who is in the group, with connection state.

Dry-run by default, like `netbird-acl.sh`, and the API token reaches curl via a
`--config` file on stdin rather than argv.

On success it prints the colleague's own instructions, including the two things
that reliably go wrong: `netbird up` silently drops its flags when the client is
already connected (so `netbird down` comes first, and there is no `netbird set`),
and sync is opt-in per repository and does nothing until `lg sync --enable` is
run in that repo. It points them at docs/hub-onboarding.md before that step
rather than after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runbook's Membership section predated `enroll-member.sh` and described only
the hub token, with a one-line "do this after the peer exists" that never said
how to make the peer exist. That left the network half as console tribal
knowledge - the half that has to happen FIRST, since a token issued before its
owner can reach the hub is a credential waiting in a chat window for a network
change to make it live.

Now four numbered steps: add the peer (one-off setup key with auto_groups, or an
existing peer by name/IP), add the hub member, hand over all three artefacts
together with the onboarding doc BEFORE `lg sync --enable`, then verify with
`--verify --from-member` from their machine - the only place the negative
criteria can be proven.

Also records two things that cost time: an empty members group right after
issuing a setup key is normal, because `auto_groups` applies at join time rather
than at creation; and `netbird up` silently drops its flags when the client is
already connected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`hub-onboarding.md` explains what syncing shares and why the decision is
irreversible. It deliberately never says which commands to run, so until now
there was nothing to hand someone that got them from "nothing installed" to
"pushed a run" - that lived in an operator's head and in the block
`enroll-member.sh` prints.

`member-quickstart.md` is that page. Six numbered steps, the three artefacts to
ask the operator for (two of which are issued once), and a troubleshooting
section made entirely of diagnostics that lie in this stack: `netbird up`
dropping its flags while connected, `/healthz` returning 200 HTML while the API
is dead, `nc -z` missing NetBird's userspace SSH listener, and `dig` bypassing
macOS scoped resolvers. Each of those has already cost someone an afternoon.

Step 5 gates on reading `hub-onboarding.md` rather than restating it - the two
pages answer different questions and should not drift into one.

Cross-linked from the README's hub section, from the runbook's hand-over step
(now four artefacts, not three), and from the script's printed block.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@datj9 datj9 self-assigned this Sep 21, 2026
@datj9
datj9 merged commit b52f824 into main Sep 21, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant